代码实例详解用BiLSTM-CRF模型进行实体抽取【珠峰书 知识图谱 深度学习 NER】
这一波人工智能的繁荣发展,深度学习功不可没,几乎可以肯定地说,没有深度学习,就没有今天人工智能的欣欣向荣。自然而然的,深度学习在各个人工智能子领域迅速渗透,自然语言处理和知识图谱也不例外地大量使用深度学习方法。在实体抽取方面,深度学习同样表现卓绝,已成为当前最常用的和效果最优的方法。本文介绍了实体抽取中最经典的深度学习模型——BiLSTM-CRF模型。
在前面几篇已经介绍了实体抽取的多种其他方法,本文所介绍的BiLSTM-CRF也是珠峰书《知识图谱:认知智能理论与实战》配套系列文章。跟随着这篇文章,能够学习到如何使用经典的深度学习模型BiLSTM-CRF来进行实体抽取。进一步的,通过本文,大家也可以看到深度学习的奇妙之处,进而加深对深度学习的了解。
BiLSTM-CRF 模型
BiLSTM-CRF(双向长短期记忆网络-条件随机场)模型在实体抽取任务中用得最多,是实体抽取任务中深度学习模型评测的基准,也是在BERT出现之前最好用的模型。在使用CRF进行实体抽取时,需要专家利用特征工程设计合适的特征函数,比如CRF++中的特征模板文件。BiLSTM-CRF则不需要利用特征工程,而是通过BiLSTM网络自动地从数据(训练语料)中学习出特征,并通过CRF计算标签的全局概率信息对输出词元序列进行解码,得到对应的标签序列。这正是深度学习方法相比于传统机器学习方法所具有的巨大优势。深度学习方法将算法人员从琐碎的特征工程中解放出来,专注于深度神经网络结构的创新,进而形成正向循环,推动了人工智能近年来的高速发展。
——珠峰书《知识图谱:认知智能理论与实战》 P117
图1是BiLSTM-CRF 模型的结构图,珠峰书《知识图谱:认知智能理论与实战》 P118~122详细解析了LSTM、BiLSTM-CRF模型的理论,想深入了解的读者可参阅相关章节。
图1 BiLSTM-CRF模型结构图,引用自珠峰书《知识图谱:认知智能理论与实战》图3-13 P118
使用飞桨(Paddle)的准备工作
飞桨框架在1.x 版本使用了静态图,并在 fluid 模块中提供了 CRF 有关的组件。但在飞桨2.x 中改为了动态图,并提醒 fluid 模块会被舍弃。
在珠峰书中,限于篇幅,并且以示例为目的,避免引入过多的其他依赖库,在CRF中选择使用了 paddle 的 fluid 模块的 CRF 组件。不过本文选用了飞桨2.x 的动态图模型构建方法,并因此引入了额外的PaddleNLP 模块。有关 PaddleNLP 可参考官方文档:
https://paddlenlp.readthedocs.io
import numpy as np
import paddle
from paddle import nn
from paddle.callbacks import VisualDL, EarlyStopping, ReduceLROnPlateau
import paddlenlp
from paddlenlp.layers import LinearChainCrf, LinearChainCrfLoss, ViterbiDecoder
print(paddle.__version__, paddlenlp.__version__)
# '2.3.2', '2.3.7'
数据准备
本例子使用 MSRA 发布的公开命名实体识别的语料,可以从【 https://github.com/wgwang/kg-book/tree/main/datasets/NER-MSRA 】下载处理好的数据集,包括训练语料train.txt和测试语料test.txt。语料的说明见该目录下的 readme。该语料相关内容也可参阅《CRF++进行实体抽取》一文的详细介绍。
读取数据
c2i = {'\001': 0}
t2i = {'O':0}
def read_data(filename, token2id, label2id):
'''读入训练语料,并转化为id
格式为适合crf++的格式:
每行格式为 token\t标签
空行表示句子结束
@param filename: 语料文件名
@param token2id: 词元到 id 的映射字典
@param label2id:标签到 id 的映射字典
'''
data = []
max_word_id = max([v for k, v in token2id.items()]) + 1
max_label_id = max([v for k, v in label2id.items()]) + 1
sent = []
lbl = []
with open(filename) as f:
for line in f:
line = line.strip()
if not line:
data.append((sent, lbl))
sent = []
lbl = []
continue
c, t = line.split("\t")
if c not in c2i:
c2i[c] = max_word_id
max_word_id += 1
ci = c2i[c]
if t not in t2i:
t2i[t] = max_label_id
max_label_id += 1
ti = t2i[t]
sent.append(ci)
lbl.append(ti)
if sent:
data.append((sent, lbl))
return data
train_data = read_data('./msra/train.txt', c2i, t2i)
print('train: ', len(train_data))
test_data = read_data('./msra/test.txt', c2i, t2i)
print('test: ', len(test_data))
print('words count:', len(c2i))
print('labels count:', len(t2i))
# train: 45057
# test: 3442
# words count: 4856
# labels count: 7
标签情况
print(t2i)
标签输出:
{'O': 0,
'B-LOC': 1,
'I-LOC': 2,
'B-ORG': 3,
'I-ORG': 4,
'B-PER': 5,
'I-PER': 6}
Paddle的Dataset
将数据集转化为 Paddle 的 Dataset 格式,方便后续给模型使用
paddle.io.Dataset 是Paddle数据集的抽象类,需要实现如下两个方法:
__getitem__: 根据给定索引获取数据集中指定样本,在 paddle.io.DataLoader 中需要使用此函数通过下标获取样本。 __len__: 返回数据集样本个数, paddle.io.BatchSampler`中需要样本个数生成下标序列。
关于 paddle.io.Dataset的详细内容,参考:https://www.paddlepaddle.org.cn/documentation/docs/zh/api/paddle/io/Dataset_cn.html
class TheDataset(paddle.io.Dataset):
def __init__(self, data):
self.data = data
def __getitem__(self, idx):
sent, lbl, length = self.data[idx]
return sent, length, lbl
def __len__(self):
return len(self.data)
转化为 Paddle 的 Dataset
def trans2TheDataset(data, max_seq_len):
data2 = []
for k, v in data:
klen = min(len(k), max_seq_len)
if len(k) < max_seq_len:
pad = [0] * (max_seq_len - len(k))
k = k + pad
v = v + pad
elif len(k) > max_seq_len:
k = k[:max_seq_len]
v = v[:max_seq_len]
data2 .append((np.asarray(k, dtype='int64'), np.asarray(v, dtype='int64'), np.asarray(klen, dtype='int64')))
return TheDataset(data2)
创建数据集
由于 msra 数据集仅提供了 train 和 test,没有 dev 数据集。这里将 train 进行二八划分为 dev 和 train 两个数据集。
另外,在输入中需要对过长的输入句子进行截断,这里设置max_seq_len为255,在实际应用中可根据情况取值。
max_seq_len = 255
# train_data 拆分为 train 和 dev
train_data_count = len(train_data)
dev_data_count = int(train_data_count * 0.2)
train_data_count -= dev_data_count
print(train_data_count, dev_data_count)
dev_data = train_data[:dev_data_count]
train_data = train_data[dev_data_count:]
print(len(train_data), len(dev_data))
train_dataset = trans2TheDataset(train_data, max_seq_len)
dev_dataset = trans2TheDataset(dev_data, max_seq_len)
test_dataset = trans2TheDataset(test_data, max_seq_len)
print(len(train_dataset), len(dev_dataset), len(test_dataset))
输出:
36046 9011
36046 9011
36046 9011 3442
构建支持获取微批数据的 Dataloader
l`DataLoader`返回一个迭代器,该迭代器根据 batch_sampler 给定的顺序迭代一次给定的 dataset
l `DataLoader`支持单进程和多进程的数据加载方式,当 num_workers 大于0时,将使用多进程方式异步加载数据。
有关 Dataloader 的详细内容, 参考:https://www.paddlepaddle.org.cn/documentation/docs/zh/api/paddle/io/DataLoader_cn.html
batch_size = 32
# 加载数据
train_loader = paddle.io.DataLoader(train_dataset, shuffle=True, batch_size=batch_size, drop_last=True)
dev_loader = paddle.io.DataLoader(dev_dataset, shuffle=False, batch_size=batch_size*2, drop_last=False)
test_loader = paddle.io.DataLoader(test_dataset, shuffle=False, batch_size=batch_size*8, drop_last=False)
for b in train_loader:
break
x, l, y = b
x.shape, l.shape, y.shape
输出:
([32, 255], [32], [32, 255])
构建BiLSTM-CRF实体抽取模型
在《知识图谱:认知智能理论与实战》一书中,对实体的定义为:
实体(Entity):是指一种独立的、拥有清晰特征的、能够区别于其他事物的事物。在信息抽取、自然语言处理和知识图谱等领域,用来描述这些事物的信息即实体。实体可以是抽象的或者具体的。
——王文广 《知识图谱:认知智能理论与实战》 P81
这是对1996年MUC-6会议对命名实体的扩展。MUC组委会在当时提出的“命名实体”任务要求从文本中识别出所有的人物名称(人名)、组织机构名称(机构名)和地理位置名称(地名),以及时间、货币和百分数的表述。如果仅仅识别人名、地名、机构名等实体的话,常见的分词库(如 jieba、HanLP、LAC 等)都支持的,可以直接使用这些库来识别,效果通常还不错。
而如果要在产业应用中进行实体抽取,仅仅能够处理这几个命名实体则远远不够。比如书名的识别、建筑物名称的识别、汽车品牌的识别、汽车零部件的识别等等。
在实践中,实体不一定是对物理事物的表述,也可以是对虚拟事物的表述。比如“经济指标”类型的实体“CPI”、人物或者组织机构发表的“观点”类型的实体、某个领域权威人物发表的“言论”类型的实体,在制造业质量和可靠性工程中的“失效事件”类型的实体,以及在各类机械与电子电器设备制造领域中的“性能”类型的实体等。
——王文广 《知识图谱:认知智能理论与实战》 P81
实体抽取(命名实体识别)就是从一段文本中抽取出符合要求的实体,常见的实体抽取方法非常多,在《知识图谱:认知智能理论与实战》的第三章介绍了主流的几种实体抽取方法。下面的模型来自于该书(珠峰书)3.5.2节《BiLSTM-CRF 模型》,详细内容参考珠峰书《知识图谱:认知智能理论与实战》一书P117~122。
值得一提的是,本文使用动态图模式构建**BiLSTM-CRF**模型。模型的结构与珠峰书清单3-17(P120)基本一致,不同的是书中使用了paddle.fluid模块,使用了静态图的方式。下面的模型则使用了 PaddleNLP 模块,使用了动态图的方式。模型的详细解析可参考珠峰书 P120~122.
PaddleNLP 中有关 CRF 的模块,参考:https://paddlenlp.readthedocs.io/zh/latest/source/paddlenlp.layers.crf.html
值得说明的是,在`LinearChainCrf`中有个参数`with_start_stop_tag`,默认为`Tru`e,表示额外的两个标签`start_tag`和`stop_tag`,是的`transitions`矩阵的大小为`[num_labels+2, num_labels+2]`,详情参阅上述链接的说明。
# BiLSTM-CRF模型
class EEModel(nn.Layer):
"""用于实体抽取(命名实体识别)的BiLSTM-CRF模型"""
def __init__(self, vocab_size, num_labels, embed_size, hidden_size, lr=0.0001):
"""
@param vocab_size: 词表大小
@param num_labels: 标签数量
@param embed_size: Embedding层的大小
@param hidden_size: BiLSTM 层的大小
@param lr: CRF的学习率
"""
super().__init__()
self.lr = lr
self.num_labels = num_labels
# 嵌入层
self.emb = nn.Embedding(vocab_size, embed_size)
# 双向LSTM
self.bilstm = nn.LSTM(embed_size, hidden_size, direction='bidirectional')
# 全连接层,映射到输出标签空间
# 这里的num_labels+2 见前述说明
self.fc = nn.Linear(hidden_size * 2, num_labels+2)
# CRF层
self.crf = LinearChainCrf(self.num_labels, crf_lr=lr)
# loss
self.loss = LinearChainCrfLoss(self.crf)
# 使用维特比解码器求解概率最大的路径
self.crf_decoding = ViterbiDecoder(self.crf.transitions)
def forward(self, sents=None, seq_len=None):
embedding = self.emb(sents)
# bilstm
output, _ = self.bilstm(embedding, sequence_length=seq_len)
emission = self.fc(output)
# 直接返回解码结果
_, prediction = self.crf_decoding(emission, seq_len)
return emission, seq_len, prediction
实例化模型
vocab_size = max([v for k, v in c2i.items()]) + 1
num_labels = max([v for k, v in t2i.items()]) + 1
embed_size = 112
hidden_size = 160
model = EEModel(vocab_size, num_labels, embed_size, hidden_size, lr=0.0001)
训练准备
learning_rate = 0.001
# 学习率预热
scheduler = paddle.optimizer.lr.LinearWarmup(
learning_rate=learning_rate, warmup_steps=300, start_lr=0.0001, end_lr=learning_rate)
# 优化器
opt = paddle.optimizer.AdamW(learning_rate=scheduler, parameters=model.parameters())
# 封装成Model
ner_model = paddle.Model(model)
# 训练时的评估指标,按标签评估模型
metric = paddlenlp.metrics.ChunkEvaluator(t2i.keys())
# 模型准备,设置loss函数,优化器,评估器
ner_model.prepare(
loss=model.loss,
optimizer=opt,
metrics=metric,
)
# 模型保存路径
save_dir=os.path.abspath("./checkpoint")
if not os.path.exists(save_dir):
os.mkdir(save_dir)
# 监测loss变化情况,3 epoch内没变化就停止,并保存最佳模型
early_stopping = EarlyStopping(monitor="loss", mode="min", patience=3)
模型训练
# 训练
ner_model.fit(
train_data=train_loader,
eval_data=dev_loader,
epochs=30,
log_freq=300,
save_dir=save_dir,
callbacks=[early_stopping]
)
评估效果
在文章《实体抽取:如何评估算法的效果?》中,详细介绍了模型效果的评估,有两种方法:
基于词元的效果评估
基于实体的效果评估
这里采用基于实体的效果评估。
id 到词元/标签
i2c = {v:k for k, v in c2i.items()}
i2t = {v:k for k, v in t2i.items()}
从结果中提取实体
def entities_of_sentence(sent, labels, seq_len=None):
'''适用于BIO标记方法'''
if type(sent) == str:
sent = sent.split()
if type(labels) == str:
labels = labels.split()
if seq_len is None:
seq_len = len(sent)
entities = {}
tokens_of_entity = []
type_of_entity = None
idx = 0
while idx < seq_len:
label = labels[idx]
word = sent[idx]
idx += 1
if label == 'O':
continue
if label.startswith('B'):
# print(tokens_of_entity, type_of_entity)
if tokens_of_entity:
if type_of_entity in entities:
entities[type_of_entity].append(
''.join(tokens_of_entity))
else:
entities[type_of_entity] = [
''.join(tokens_of_entity)]
tokens_of_entity = [word]
# B-type, 比如B-ORG表示ORG类型
type_of_entity = label[2:]
continue
if label.startswith('I'):
# I-type, 比如I-ORG表示ORG类型
if label[2:] != type_of_entity:
# B-type 和 I-type不同,说明抽取结果有误
# 删除该抽取结果
tokens_of_entity = []
type_of_entity = None
else:
tokens_of_entity.append(word)
if tokens_of_entity:
if type_of_entity in entities:
entities[type_of_entity].append(
''.join(tokens_of_entity))
else:
entities[type_of_entity] = [
''.join(tokens_of_entity)]
return entities
x, y = test_data[0]
x = [i2c[i] for i in x]
y = [i2t[i] for i in y]
entities_of_sentence(x, y)
计算 F1值
def evaluate_entities(gt, preds):
'''计算根据类别加权的宏观F1分数'''
f1s = []
for cate in gt.keys():
y = set(gt[cate])
y_hat = set(preds[cate])
y_i = y.intersection(y_hat)
p, r, f1 = 0, 0, 0
if y_i:
p = len(y_i) / len(y)
r = len(y_i) / len(y_hat)
f1 = 2 * (p * r) / (p + r)
f1s.append(f1)
return sum(f1s) / len(f1s)
使用测试集测试
model.eval()
ee_gt = {}
ee_pred = {}
with paddle.no_grad():
for sents, sls, labels in test_loader:
_, _, preds = model.forward(sents, sls)
sls = sls.tolist()
for sent, sl, label, pred in zip(sents, sls, labels, preds):
sent = [i2c[i] for i in sent.tolist()]
label = [i2t[i] for i in label.tolist()]
pred = [i2t[i] for i in pred.tolist()]
es_gt = entities_of_sentence(sent, label, sl)
for k, v in es_gt.items():
if k not in ee_gt:
ee_gt[k] = []
ee_gt[k].extend(v)
es_pred = entities_of_sentence(sent, pred, sl)
for k, v in es_pred.items():
if k not in ee_pred:
ee_pred[k] = []
ee_pred[k].extend(v)
计算测试集的 F1值
evaluate_entities(ee_gt, ee_pred)
计算每个类别的 F1值
for cate in ee_gt.keys():
y = set(ee_gt[cate])
y_hat = set(ee_pred[cate])
y_i = y.intersection(y_hat)
p, r, f1 = 0, 0, 0
if y_i:
p = len(y_i) / len(y)
r = len(y_i) / len(y_hat)
f1 = 2 * (p * r) / (p + r)
print(cate, f1)
总结
本文详细示例了使用飞桨框架构建 BiLSTM-CRF 模型进行实体抽取的过程,配合珠峰书《知识图谱:认知智能理论与实战》使用,读者能够完全理解并在实际项目或业务中使用该模型来抽取实体,进而构建出知识图谱。本文所使用的数据以及完整代码可从https://github.com/wgwang/kg-book上获取。
从这个完整的示例可以看出,使用深度学习的方法进行实体抽取,其好处是不需要特征工程(比如 CRF++中的特征模板设置),仅需要标注好的训练语料即可。在大量的实践中也证实了,通过足够的标注语料,深度学习的方法能够比精心特征工程结合传统机器学习的方法效果更优,而这正是深度学习强大威力之所在,也是驱动这一波人工智能繁荣的原因之所在。
诚然,BiLSTM-CRF是非常经典有效有用且强大的模型,但一山还有一山高,读过珠峰书的读者知道,后面还有更好的深度学习模型。
参考材料
王文广. 知识图谱:认知智能理论与实战[M]. 北京:电子工业出版社, 2022: P
Paddle官方文档:
PaddleNLP 官方文档:
本文代码及数据可从https://github.com/wgwang/kg-book上获取